React 中的副作用和远程状态

January 28, 2022

本文只讨论 React 16.8 以来的函数组件和 hook 模型,不讨论历史的 class 组件和生命周期模型

在编写 React 应用时,我们常常会需要发送网络请求以从服务端获取我们渲染界面所需要的数据。以往一种常见的实践是在 useEffect 钩子中去发送网络请求,一个简单的示例如下:

function Post({ id }) {
  const [content, setContent] = useState("")

  useEffect(() => {
    fetchPostContent(id).then(content => setState(content))
  }, [id])

  return <article>{content}</article>
}

在上面的示例中,<Post> 组件接收一个 id 属性,并渲染对应 ID 的博文内容。实现上,使用一个状态 (state) 来存放博文的内容,其初始值为空字符串,然后在 useEffect 中根据 id 的变化发送网络请求来获取博文的内容,更新这个状态,引起组件的重新渲染,从而显示出博文内容。

这样的实践很常见,但有一些显而易见的缺陷。例如竞态问题:设想 id 属性初始为 1,fetchPostContent(1) 网络请求发出,此时 id 属性变更为 2,fetchPostContent(2) 请求发出,此二请求谁先返回并不能保证,因此可能导致 id 属性为 2 而显示的是 ID 为 1 的博文内容。

解决这一问题也并不难,例如可以在 fetchPostContent 方法中做处理,当参数变化时中断上一个请求等等,当然这样的方式又需要与其他的错误情况做区分等等更多的处理,实践过的朋友一定都有体验。

在拆东墙补西墙之前,我们不妨想一想这样的流程为何会导致这样的问题。

副作用

首先我想对齐一个前提:useEffect 不是监听器,而是用来声明在组件渲染过程中运行的的副作用 (effect) 的。如果你常将 useEffect 当作监听器来用,不妨阅读 React 官网对 useEffect 依赖的解释——用于跳过副作用运行

在我看来,所谓副作用即应当是在 ui=f(prop,state) 的等式中不影响 ui 输出的存在。换句话说,如果移除一个 useEffect 声明,渲染结果应当不受影响。例如:

function App({ title, content }) {
  useEffect(() => {
    document.title = title
  })

  return children
}

在上面的示例中,我们使用 useEffect 来设置页面标题。这一动作不作用于组件输出的 UI,而是对组件外部产生影响,因此是一个副作用。如果我们移除这段 useEffect,组件输出的 UI 将不受影响。而在我们在 useEffect 中请求博文内容的示例中,组件输出的 UI 却严重依赖了一个副作用的结果,这正是导致输出结果不可靠的原因。找到了问题的原因,我们重新来审视 <Post id> 组件。我们想要输出的“对应 ID 的博文内容”不应该是副作用的结果,那么它是什么呢?这里我想引入“远程状态”这一概念。

远程状态

在 React 应用中,我们会使用 useState 钩子来声明一个状态,我称作本地状态。远程状态就像本地状态一样,它有一个确定的值,只不过本地状态的值保存在本地,远程状态的值保存在服务端。这一概念并不难理解,其实 Web 应用的本质就是读取和修改远程状态

我们看如下引入了远程状态概念的示例:

function Post({ id }) {
  const content = usePostContent(id)

  return <article>{content}</article>
}

此示例中,我们拥有一个 usePostContent 钩子,它接收博文的 ID,返回其内容。这一 usePostContent 钩子其实并不难实现,实际上,它看起来仅仅是把刚才的 useEffect 那些东西包装了起来。不过这次我们增加了竞态处理,使用一个 posts 对象来根据 id 返回对应的博文内容。

function usePostContent(id) {
  const [posts, setPosts] = useState({})

  useEffect(() => {
    fetchPostContent(id).then(content => {
      setPosts(previous => {
        return {
          ...previous,
          [id]: content,
        }
      })
    })
  }, [id])

  return posts[id] ?? ""
}

仅仅是一个简单的封装,让我们的 <Post id> 组件形成了一个干干净净的 ui=f(props) 范式。

如果你恰好觉得“既然知道封装是怎么实现的了那就跟没封装没区别了”,那你可以不看了。

社区实践

目前的封装比较简陋,如果要应对如下一些情况,实现就会变得复杂起来,比如:

  • 如果要接收的参数不仅仅是一个 id,而是多个参数,如何存放保证根据参数返回正确的结果
  • 错误处理

这些还仅仅是对于某一个远程状态的封装,而系统中往往存在许多远程状态,每一个都实现一遍更是繁琐。但聪明的你一定想到了,可以把重复的部分再封装一个通用的钩子,每个远程状态里面调这个钩子。这件事已经有人替你做了,社区非常流行的 react-queryswr 库正是起到这个作用。以 react-query 为例改造我们的 usePostContent

import { useQuery } from "react-query"

function usePostContent(id) {
  return useQuery(["posts", id], () => fetchPostContent(id))
}

其他的放结语里

引入“远程状态”的视角还能够帮助我们看清本地状态的作用——存放 UI 相关的状态。例如:对话框的开闭、表单状态等等。而诸如列表数据这些,应该视作远程状态,如果发现在设计组件时使用了本地状态来存放,可以多斟酌一下。


Profile picture

Written by Doma who just migrated his blog to Gatsby.js. You should follow him on Twitter and GitHub.